
在 Day 10~Day 14,我們把「主要熱點」搬進 Redis,並用 Lua Script 保證原子性與語義完整。
今天該把這顆引擎裝進一台能跑的車:用 Gin 把「搶票」暴露為一個乾淨、可維護、可觀測的 HTTP API。
這不是單純把 client.DecrBy 包起來丟給網路。API 是系統的「契約 (Contract)」與「承諾 (SLO)」。
在這裡做的每個決定——路徑、輸入、輸出、狀態碼、超時、錯誤映射、日誌、追蹤——都會直接影響可維護性與風險暴露面。
POST /api/v1/purchase
{
  "ticket_id": 12345,
  "user_id": 67890
}
{
  "request_id": "2a3b4c...",
  "status": "success",
  "remaining": 998
}
{
  "request_id": "2a3b4c...",
  "status": "error",
  "error": {
    "code": "SOLD_OUT",
    "message": "票券已售罄"
  }
}
HTTP 狀態碼策略:
關鍵點:不要把所有錯誤都 200 包起來。HTTP 狀態碼是協議的一部分,它讓前端和觀測系統能快速判斷行為,不需要解析字串。
領域錯誤到 HTTP 的對照表:
| Domain Error | 說明 | HTTP | code | 
|---|---|---|---|
| ErrSoldOut | 票券無庫存 | 409 | SOLD_OUT | 
| ErrAlreadyPurchased | 用戶已購買 | 409 | DUPLICATE_PURCHASE | 
| ErrInvalidArgument | 參數不合法 | 400 | INVALID_ARGUMENT | 
| 其他 error | 非預期 | 500 | INTERNAL | 
堅持「單一映射來源」:在 Use Case 層用 errors.Is 判斷並回傳對應的 ApiError,Handler 只做翻譯與輸出,避免到處散落 switch。
server:Gin 啟動、路由、中介層(Correlation ID、Recovery、JSON Content-Type)。handler:HTTP 參數綁定與回應格式化。usecase:購買流程協調(呼叫 TicketService)。service:TicketService 基於 Redis + Lua 執行原子扣減與去重。infra:Redis Client 建置與生命周期管理。這樣分層的意義是把「IO、協議、領域規則」分開:可測、可替換、不相互污染。

依賴:
go get github.com/gin-gonic/gin github.com/redis/go-redis/v9 github.com/google/uuid
package main
import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "time"
    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "github.com/redis/go-redis/v9"
)
// ===== Domain errors =====
var (
    ErrSoldOut          = errors.New("sold out")
    ErrAlreadyPurchased = errors.New("already purchased")
    ErrInvalidArgument  = errors.New("invalid argument")
)
// ===== Infra: Redis client =====
func newRedisClient() *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:         "localhost:6379",
        PoolSize:     200,
        MinIdleConns: 20,
        ReadTimeout:  500 * time.Millisecond,
        WriteTimeout: 500 * time.Millisecond,
        PoolTimeout:  1 * time.Second,
    })
}
// ===== Service: TicketService (Lua 保證原子性) =====
type TicketService struct{ rdb *redis.Client }
func NewTicketService(rdb *redis.Client) *TicketService { return &TicketService{rdb: rdb} }
var purchaseScript = redis.NewScript(`
local ticketKey = KEYS[1]
local userKey = KEYS[2]
local ticketID = ARGV[1]
local quantity = tonumber(redis.call('GET', ticketKey) or '0')
if quantity <= 0 then return 0 end
if redis.call('SISMEMBER', userKey, ticketID) == 1 then return -1 end
redis.call('DECRBY', ticketKey, 1)
redis.call('SADD', userKey, ticketID)
return 1
`)
func (s *TicketService) Purchase(ctx context.Context, ticketID, userID int64) (int64, error) {
    ticketKey := fmt.Sprintf("ticket:%d", ticketID)
    userKey := fmt.Sprintf("user:%d:purchased", userID)
    res, err := purchaseScript.Run(ctx, s.rdb, []string{ticketKey, userKey}, ticketID).Int64()
    if err != nil { return 0, err }
    switch res {
    case 1:
        // 回傳剩餘量(再取一次避免業務和腳本糾纏)
        remain, err := s.rdb.Get(ctx, ticketKey).Int64()
        if err != nil { return 0, err }
        return remain, nil
    case 0:
        return 0, ErrSoldOut
    case -1:
        return 0, ErrAlreadyPurchased
    default:
        return 0, errors.New("unknown script result")
    }
}
// ===== Usecase =====
type PurchaseUsecase struct{ svc *TicketService }
func NewPurchaseUsecase(svc *TicketService) *PurchaseUsecase { return &PurchaseUsecase{svc: svc} }
func (u *PurchaseUsecase) Execute(ctx context.Context, ticketID, userID int64) (int64, error) {
    if ticketID <= 0 || userID <= 0 { return 0, ErrInvalidArgument }
    return u.svc.Purchase(ctx, ticketID, userID)
}
// ===== HTTP layer =====
type purchaseReq struct {
    TicketID int64 `json:"ticket_id" binding:"required"`
    UserID   int64 `json:"user_id" binding:"required"`
}
func correlationID() gin.HandlerFunc {
    return func(c *gin.Context) {
        rid := c.GetHeader("X-Request-ID")
        if rid == "" { rid = uuid.NewString() }
        c.Set("request_id", rid)
        c.Writer.Header().Set("X-Request-ID", rid)
        c.Next()
    }
}
func main() {
    rdb := newRedisClient()
    defer rdb.Close()
    if err := rdb.Ping(context.Background()).Err(); err != nil { panic(err) }
    svc := NewTicketService(rdb)
    uc := NewPurchaseUsecase(svc)
    r := gin.New()
    r.Use(gin.Recovery(), correlationID())
    r.POST("/api/v1/purchase", func(c *gin.Context) {
        var req purchaseReq
        if err := c.ShouldBindJSON(&req); err != nil {
            respondError(c, http.StatusBadRequest, code("INVALID_ARGUMENT"), err.Error())
            return
        }
        ctx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
        defer cancel()
        remaining, err := uc.Execute(ctx, req.TicketID, req.UserID)
        if err != nil {
            switch {
            case errors.Is(err, ErrInvalidArgument):
                respondError(c, http.StatusBadRequest, code("INVALID_ARGUMENT"), "參數不合法")
            case errors.Is(err, ErrSoldOut):
                respondError(c, http.StatusConflict, code("SOLD_OUT"), "票券已售罄")
            case errors.Is(err, ErrAlreadyPurchased):
                respondError(c, http.StatusConflict, code("DUPLICATE_PURCHASE"), "用戶已購買此票券")
            default:
                respondError(c, http.StatusInternalServerError, code("INTERNAL"), "系統錯誤")
            }
            return
        }
        c.JSON(http.StatusOK, gin.H{
            "request_id": c.GetString("request_id"),
            "status":     "success",
            "remaining":  remaining,
        })
    })
    _ = r.Run(":8080")
}
type errPayload struct {
    RequestID string      `json:"request_id"`
    Status    string      `json:"status"`
    Error     interface{} `json:"error"`
}
func code(c string) map[string]string { return map[string]string{"code": c} }
func respondError(c *gin.Context, httpStatus int, meta map[string]string, msg string) {
    payload := errPayload{
        RequestID: c.GetString("request_id"),
        Status:    "error",
        Error: gin.H{
            "code":    meta["code"],
            "message": msg,
        },
    }
    c.JSON(httpStatus, payload)
}
說明:
X-Request-ID 可從前端傳入或後端生成,回寫回應,支撐後續日誌與追蹤。purchaseScript 用 redis.Script,自動處理 NOSCRIPT(快取失效)情形。remaining 透過二次 GET 取得,避免把輸出耦合進腳本(可測性更好)。X-Request-ID,向下游(之後的 MQ)傳遞。Content-Type: application/json,禁用非 JSON 回應。request_id、route、ticket_id、user_id、latency_ms、status、code。使用 k6 針對 POST /api/v1/purchase:
PoolStats:Misses/Timeouts/TotalConns/IdleConns 必須落在期望範圍。X-Request-ID → 事件難以串起。